Skip to content

fix(logs): restore workflow input on log details for runs without secret provenance - #6278

Closed
mzxchandra wants to merge 4 commits into
stagingfrom
fix/logs-empty-input-json
Closed

fix(logs): restore workflow input on log details for runs without secret provenance#6278
mzxchandra wants to merge 4 commits into
stagingfrom
fix/logs-empty-input-json

Conversation

@mzxchandra

Copy link
Copy Markdown
Contributor

Summary

Workflow Input JSON renders empty on the logs page for a large class of executions. This restores it on the read path.

Root cause. PR #6000 added a resolved-secret gate to the log display projection. When a run's persisted resolvedSecretTraceProvenance is absent or incomplete, projectTraceSpansForSecrets returns structural-only spans, which stripped the entire display envelope - including workflowInput and finalOutput. Every run written before #6000 has no provenance key at all, so all of them went blank at once. The data was never lost; it is intact in storage and functional readers see it fine.

Fix (fix(logs): restore workflow input ...). The display envelope is split in two:

  • Gated (finalOutput, blockInput, blockExecutions, error, errorDetails, completionFailure, message) keeps the existing fix(sanitization): secret exposure in function and agent trace spans #6000 gate and fails closed. Trace-span redaction is untouched.
  • Boundary (workflowInput only) goes through a new projectWorkflowBoundarySpansForSecrets. With complete provenance it is byte-identical to the current matcher path; with absent or incomplete provenance the content survives instead of collapsing to structure.

workflowInput is exempt because an inbound trigger payload is captured before any secret is resolved. That premise does not hold for nested runs - a workflow or custom_block execution is handed workflowInput assembled from its parent's already-resolved block outputs (workflow-handler.ts:578), so those keep the gated treatment. The check is a denylist of nested trigger types, because webhook runs record the provider (zoho_desk, slack) as trigger.type and an allowlist of "known inbound" types would fail closed on exactly the population this fixes.

Runs predating top-level workflowInput persistence recover it from the trigger block state, gated on an absent provenance key plus present block states - together these identify pre-stamping data without dating the row. Recovery refuses when more than one block state matches the trigger shape, because a paused run carries a resume placeholder with the same executed:false / executionTime:0 / output != null shape and its capability URL must never render as the workflow input.

Refactor (refactor(logs): extract the legacy workflow-input reader ...). extractLegacyWorkflowInput moves out of execution-state.ts into a shared module so the display path can use it without a circular import. The functional re-run reader keeps first-match behavior; only the display path refuses to guess.

Deliberately not in scope: finalOutput fails closed. It sits downstream of every block and can carry a resolved secret, and on the incomplete path there is no matcher to redact with.

Test Coverage

CODE PATHS                                                   TESTS
[+] lib/logs/execution/legacy-workflow-input.ts (new)
  ├── extractLegacyWorkflowInput()  (moved verbatim)
  │   └── [★★★ TESTED] execution-state.test.ts (pre-existing, still green)
  ├── hasPersistedBlockStates()
  │   └── [★★★ TESTED] empty {} | missing key | non-record | no executionState
  ├── isNestedInputSiblingClone()
  │   └── [★★★ TESTED] clone shape kept flat vs superset shape narrowed
  └── recoverLegacyWorkflowInputForDisplay()
      └── [★★★ TESTED] narrowing, token drop, non-record passthrough,
                        ambiguous-shape refusal (HITL resume placeholder)

[+] lib/logs/execution/trace-secret-projection.ts
  └── projectWorkflowBoundarySpansForSecrets()
      └── [★★★ TESTED] complete → delegate | absent/incomplete → clone
                        | clone exceeds limits → structural fallback

[+] lib/logs/execution/trace-store.ts
  └── projectExecutionDataForDisplay()
      ├── [★★★ TESTED] boundary/gated split, recovery gate (3 conditions)
      ├── [★★★ TESTED] nested workflow/custom_block gated; provider webhook exempt
      └── [★★★ TESTED] traceSpans array vs malformed; empty-envelope short-circuit

COVERAGE: 22/23 paths (96%). The one uncovered path is a defensive type guard in
copyProjectedEnvelope, unreachable through the public API.

Tests: 1487 files before → 1487 after (no new test files; 14 cases added to 3 existing files).

Pre-Landing Review

No issues found. Notable verifications rather than assumptions:

  • Concurrency: the diff introduces Promise.all over two projections sharing one store. createProjectionContext builds a fresh context per call and all mutable state is per-context; store is read-only config. No race.
  • Enum completeness: LOG_DISPLAY_CONTENT_KEYS was split into two tuples - proved the union is the identical 8-key set, the constant is module-private with one consumer, and neither synthetic span id has an external consumer or leaks into displayData.traceSpans.

Adversarial Review

Codex found three real issues that three of my own review passes missed. All fixed and re-verified (GATE: PASS):

[P1] Nested workflow/custom_block runs receive workflowInput from the parent's
     resolved block outputs (workflow-handler.ts:578), so the "pre-resolution"
     premise is false for them.
     → FIXED: nested trigger types keep workflowInput gated.

[P2] buildApiOrInputOutput:477 writes an object input as {...input, input: {...input}},
     so the nested `input` is a clone of its siblings and the ORIGINAL workflowInput
     was the flat object. Narrowing that to { input } displays a shape the run
     never received.
     → FIXED: narrow only when the nested value is not a sibling clone.

[High] The legacy recovery heuristic can match a non-trigger block —
     block-executor.ts:833 writes a HITL resume placeholder with the identical
     executed:false / executionTime:0 / output shape.
     → FIXED: recovery refuses when more than one candidate matches.

[Medium] cloneTraceSpanForProjection is a shallow spread — it bounds span
     structure, not payload size. TSDoc called it a "bounded clone".
     → FIXED: documentation corrected to state exactly what it bounds.

Design Review

No frontend files changed - design review skipped. (gstack-diff-scope reports SCOPE_FRONTEND=true, but that is a false positive from the repo shape; all 6 changed files are apps/sim/lib/**/*.ts.)

Eval Results

No prompt-related files changed - evals skipped.

Scope Drift

Scope Check: CLEAN. Intent was to restore workflow input/output on the logs read path; delivered exactly that in 4 source files (1 new) plus 3 test files. No write-path, schema, or API-contract changes.

Plan Completion

13 DONE, 3 CHANGED, 0 NOT DONE, 0 UNVERIFIABLE.

The 3 CHANGED items are all narrowings made during implementation, each with evidence:

  • Plan proposed exempting finalOutput alongside workflowInput; it now fails closed instead.
  • Plan's Part B proposed fixing a provenance "attachment gap" and webhook monotonicity. Investigation showed neither exists: every terminal path funnels through completeExecutionWithFinalization, which stamps, and onEnvironmentSnapshot fires unconditionally. Verified empirically (108 legacy blobs with the key absent, 5 current-code blobs with it present, no overlap) and pinned with a logging-session test rather than a production change.

Verification Results

Verified end to end against a live instance (Next.js + realtime + Postgres), using a second server running staging against the same database as an A/B baseline.

  • Blast radius, measured: diffed every display key for 137 executions across both servers. 65 byte-identical, 72 differ only on workflowInput, 0 differ on any other key, 0 lost input.
  • Real legacy data: 108 pre-fix(sanitization): secret exposure in function and agent trace spans #6000 runs, all provenance-absent. Zoho Desk webhook runs go from blank to rendering the full payload; baseline stays blank with identical structural trace spans.
  • Forensic shapes: seeded fixtures for the three real blob shapes. Slack envelope keeps 12 keys with token dropped and the value absent from the whole response; nested .input narrows with all 8 hoisted siblings gone; byte-identical shape renders verbatim.
  • Fail-closed: a complete:false run renders input while withholding finalOutput, blockInput, and span content.
  • Regression: a fresh complete-provenance run is byte-identical between the two servers.
  • Functional reader: re-ran a workflow with inputFromExecutionId pointing at a legacy row with no top-level input; it went through the moved extractor and inherited the payload exactly.

Known limitations

  • Failed and cancelled runs still show no input. That is a separate, pre-existing write-side bug: completeExecutionWithFinalization accepts workflowInput, and the completed and paused callers pass it, but force_failed, cancelled, and the fallback do not - so those runs persist no input at all and no read-path change can recover it. 42 such runs in the environment I tested. Deliberately left for a separate PR to keep this one read-path only.
  • workflowInput: {} renders as an empty panel for runs that genuinely had no input (a manual run of a workflow whose Start block has no configured input fields). This matches pre-fix(sanitization): secret exposure in function and agent trace spans #6000 behavior and is intentional.

Test plan

  • Full suite: 18512 passing / 1367 files
  • tsc --noEmit clean, biome check clean
  • check:api-validation, check:boundaries, check:client-boundary, check:utils all pass
  • E2E verified against a live instance with an A/B baseline (see Verification Results)

Two tests fail only under full-suite parallel load and pass in isolation - markdown-parse (a 400-seed property test with a 30s ceiling that takes ~10s idle and >45s loaded) and diff-engine. Both are in files this PR does not touch and were failing on staging before it.

🤖 Generated with Claude Code

https://claude.ai/code/session_015BdMTqfpyGk33ExErtiPkZ

…d module

Moves extractLegacyWorkflowInput out of execution-state.ts so both the
functional re-run reader and the log display projection can use it without
a circular import (execution-state already imports trace-store).

Adds two display-only helpers alongside it: hasPersistedBlockStates, and
recoverLegacyWorkflowInputForDisplay, which narrows the raw trigger block
output to the shape workflowInput originally held. The functional reader
keeps first-match behavior; only the display path refuses to guess.

No behavior change to re-execution.
…ret provenance

PR #6000 added a resolved-secret gate to the log display projection: when a
run's persisted resolvedSecretTraceProvenance is absent or incomplete,
projectTraceSpansForSecrets returns structural-only spans, which stripped the
whole display envelope. That silently blanked the Input and Output panels for
every run written before #6000, and for any run whose registry goes incomplete.

Splits the display envelope in two. Per-block content (finalOutput, blockInput,
blockExecutions, errors, trace spans) keeps the existing gate and fails closed.
Only workflowInput is exempted, via projectWorkflowBoundarySpansForSecrets:
with complete provenance it is byte-identical to the current matcher path, and
with absent or incomplete provenance the content survives instead of collapsing.

workflowInput is exempt because an inbound trigger payload is captured before
any secret is resolved. That premise does NOT hold for nested runs: a workflow
or custom_block execution is handed workflowInput built from its parent's
already-resolved block outputs, so those keep the gated treatment. The check is
a denylist of nested trigger types, because webhook runs record the provider
(zoho_desk, slack) as the trigger type and an allowlist would fail closed on
exactly the population this fixes.

Runs written before workflowInput was persisted recover it from the trigger
block state, gated on an absent provenance key plus present block states, which
together identify pre-stamping data without dating the row. Recovery refuses
when more than one block state matches the trigger shape, since a paused run
carries a resume placeholder with the same shape and its capability URL must
never render as the workflow input.

Verified against 137 executions on a live instance: the only display key that
differs from the pre-fix baseline is workflowInput, on 72 runs, none lost.
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 5, 2026 3:31am

Request Review

@cursor

cursor Bot commented Aug 5, 2026

Copy link
Copy Markdown

PR Summary

Cursor Bugbot is generating a summary for commit a4cd453. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR separates workflow-boundary input from downstream log content so legacy inbound input can render when secret provenance is unavailable, while retaining fail-closed projection for execution-derived content. It also extracts legacy input recovery and records source-execution metadata for inputs copied from prior runs.

  • Adds boundary-specific trace projection and legacy workflow-input recovery.
  • Gates nested and marked inherited inputs through the existing secret-safe projection.
  • Extends execution logging and focused tests for provenance and display behavior.

Confidence Score: 4/5

This PR is not yet safe to merge because execution-derived input can still be displayed without redaction on async/background and SSE runs.

The reply states that the issue was fixed in 4717819, but the route stamps the source execution only on its own LoggingSession; async/background and SSE execution create separate sessions without that marker, so the display reader still classifies copied parent-resolved input as exempt boundary content.

Files Needing Attention: apps/sim/app/api/workflows/[id]/execute/route.ts, apps/sim/background/workflow-execution.ts, apps/sim/lib/workflows/executor/execute-workflow.ts

Important Files Changed

Filename Overview
apps/sim/app/api/workflows/[id]/execute/route.ts Marks copied input on the route-owned logging session before dispatching execution.
apps/sim/lib/logs/execution/logging-session.ts Adds source-execution metadata to trigger data when configured on a session.
apps/sim/lib/logs/execution/trace-store.ts Splits boundary and gated display envelopes and uses trigger metadata to gate inherited input.
apps/sim/lib/logs/execution/trace-secret-projection.ts Adds a boundary projection that preserves content when provenance is unavailable while retaining structural limits.
apps/sim/lib/logs/execution/legacy-workflow-input.ts Extracts legacy input lookup and adds conservative display-specific recovery and shape normalization.

Sequence Diagram

sequenceDiagram
  participant API as Execute API
  participant RouteSession as Route LoggingSession
  participant Worker as Async/SSE executor
  participant RunSession as Persisting LoggingSession
  participant Store as Execution log
  participant Display as Log projection
  API->>RouteSession: setInputSourceExecutionId(source)
  API->>Worker: pass resolved input
  Worker->>RunSession: create fresh session
  RunSession->>Store: persist trigger without source marker
  Display->>Store: read workflowInput
  Display->>Display: treat as boundary input
Loading

Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'origin/sta..." | Re-trigger Greptile

Comment thread apps/sim/lib/logs/execution/trace-store.ts
Greptile review: the boundary exemption assumed a non-nested trigger implies an
inbound payload. A re-run with inputFromExecutionId breaks that. It copies the
source run's workflowInput verbatim, and the destination presents whatever
trigger type its caller asked for, so a value resolved inside a custom_block
parent could arrive in a manual run and be exempted. The destination's own
provenance cannot describe the source's secret resolution, so no matcher covers
it.

The read path could not tell an inherited input from a fresh one, so the re-run
now records its source: LoggingSession.setInputSourceExecutionId stamps it into
the existing trigger data channel, and the display projection withholds the
exemption whenever it is present. Nested runs and inherited-input runs share one
predicate - neither is a pre-resolution inbound payload.

Adds setInputSourceExecutionId to the shared @sim/testing LoggingSession mock so
the execute-route suites exercise the real call.
@mzxchandra

Copy link
Copy Markdown
Contributor Author

@greptile review

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@cursor review

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@greptile

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 983be88. Configure here.

*/
if (inputFromExecutionId) {
loggingSession.setInputSourceExecutionId(inputFromExecutionId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async rerun omits input source

High Severity

For async runs with inputFromExecutionId, the setInputSourceExecutionId call on the route's LoggingSession isn't persisted. The worker creates a new session that lacks this ID, resulting in incomplete provenance. This can inadvertently expose copied secrets from the source execution in logs under the workflowInput boundary.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 983be88. Configure here.

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@icecrasher321

@icecrasher321

Copy link
Copy Markdown
Collaborator

fixed here centrally #6247

@waleedlatif1
waleedlatif1 deleted the fix/logs-empty-input-json branch August 5, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants